BIO,NIO,AIO IO模型总结

Java 中的 BIO、NIO和 AIO 理解为是 Java 语言对操作系统的各种 IO 模型的封装。
程序员在使用这些 API 的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。
只需要使用Java的API就可以了。

同步与异步

同步:

同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。

异步:

异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,
被调用者通常依靠事件,回调等机制来通知调用者其返回结果。

同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。

阻塞和非阻塞

阻塞:

阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。

非阻塞:

非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

1
2
3
4
5
举个生活中简单的例子,你妈妈让你烧水,

小时候你比较笨啊,在那里傻等着水开(同步阻塞)。
等你稍微再长大一点,你知道每次烧水的空隙可以去干点其他事,然后只需要时不时来看看水开了没有(同步非阻塞)。
后来,你们家用上了水开了会发出声音的壶,这样你就只需要听到响声后就知道水开了,在这期间你可以随便干自己的事情,你需要去倒水了(异步非阻塞)。

BIO (Blocking I/O)

同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。

传统 BIO

BIO通信(一请求一应答)

采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。
我们一般通过在while(true) 循环中服务端会调用 accept() 方法等待接收客户端的连接的方式监听请求,
请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,
只能等待同当前连接的客户端的操作执行完成, 不过可以通过多线程来支持多个客户端的连接

如果要让 BIO 通信模型能够同时处理多个客户端请求,就必须使用多线程
(主要原因是socket.accept()、socket.read()、socket.write() 涉及的三个主要函数都是同步阻塞的),
也就是说它在接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,
通过输出流返回应答给客户端,线程销毁。这就是典型的 一请求一应答通信模型 。
我们可以设想一下如果这个连接不做任何事情的话就会造成不必要的线程开销,不过可以通过线程池机制改善,
线程池还可以让线程的创建和回收成本相对较低。使用FixedThreadPool 可以有效的控制了线程的最大数量,
保证了系统有限的资源的控制,实现了N(客户端请求数量):M(处理客户端请求的线程数量)的伪异步I/O模型(N 可以远远大于 M)

我们再设想一下当客户端并发访问量增加后这种模型会出现什么问题?

1
2
3
4
Java 虚拟机中,线程是宝贵的资源,线程的创建和销毁成本很高,除此之外,线程的切换成本也是很高的。
尤其在 Linux 这样的操作系统中,线程本质上就是一个进程,创建和销毁线程都是重量级的系统函数。
如果并发访问量增加会导致线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终导致进程宕机或者僵死,
不能对外提供服务。

Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class IOClient {

public static void main(String[] args) {
// TODO 创建多个线程,模拟多个客户端连接服务端
new Thread(() -> {
try {
Socket socket = new Socket("127.0.0.1", 3333);
while (true) {
try {
socket.getOutputStream().write((new Date() + ": hello world").getBytes());
Thread.sleep(2000);
} catch (Exception e) {
}
}
} catch (IOException e) {
}
}).start();

}

}



public class IOServer {

public static void main(String[] args) throws IOException {
// TODO 服务端处理客户端连接请求
ServerSocket serverSocket = new ServerSocket(3333);

// 接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理
new Thread(() -> {
while (true) {
try {
// 阻塞方法获取新的连接
Socket socket = serverSocket.accept();

// 每一个新的连接都创建一个线程,负责读取数据
new Thread(() -> {
try {
int len;
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
// 按字节流方式读取数据
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
} catch (IOException e) {
}
}).start();

} catch (IOException e) {
}

}
}).start();

}

}

伪异步 IO

为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化一一一
后端通过一个线程池来处理多个客户端的请求接入,
形成客户端个数M:线程池最大线程数N的比例关系,其中M可以远远大于N.
通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。

采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架

当有新的客户端接入时,将客户端的 Socket 封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中
进行处理,JDK 的线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小
和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

伪异步I/O通信框架采用了线程池实现,因此避免了为每个请求都创建一个独立线程造成的线程资源耗尽问题。
不过因为它的底层仍然是同步阻塞的BIO模型,因此无法从根本上解决问题。

总结

在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O
并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的
连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O
处理模型来应对更高的并发量。

NIO (New I/O)

NIO是一种同步非阻塞的I/O模型,对应 java.nio 包,提供了 Channel , Selector,Buffer等抽象。

NIO中的N可以理解为Non-blocking,不单纯是New。它支持面向缓冲的,基于通道的I/O操作方法。
NIO提供了与传统BIO模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道
实现,两种通道都支持阻塞和非阻塞两种模式。

阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;
非阻塞模式正好与之相反。

对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;
对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。

区别:(关键)

1) Non-blocking IO(非阻塞IO)
Java IO流:
阻塞的。这意味着,当一个线程调用 read() 或 write() 时,该线程被阻塞,
直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

NIO流:
不阻塞的。Java NIO使我们可以进行非阻塞IO操作。
比如说,单线程中从通道读取数据到buffer,同时可以继续做别的事情,当数据读取到buffer中后,
线程再继续处理数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入一些数据到某通道,
但不需要等待它完全写入,这个线程同时可以去做别的事情。

2)Buffer(缓冲区)
IO 面向流(Stream oriented),而 NIO 面向缓冲区(Buffer oriented)。
Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中加入Buffer对象,体现了新库与原I/O的一个重要区别。
Java NIO Buffers用于和NIO Channel交互。 我们从Channel中读取数据到buffers里,从Buffer把数据写入到Channels;
Buffer本质上就是一块内存区;

一个Buffer有三个属性是必须掌握的,分别是:capacity容量、position位置、limit限制。

IO:
在面向流的I/O中·可以将数据直接写入或者将数据直接读到 Stream 对象中。
虽然 Stream 中也有 Buffer 开头的扩展类,但只是流的包装类,还是从流读到缓冲区,
而 NIO 却是直接读到 Buffer 中进行操作。

NIO流:
在NIO厍中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;
在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
最常用的缓冲区是 ByteBuffer,一个 ByteBuffer 提供了一组功能用于操作 byte 数组。
除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean类型)都对应有一种缓冲区。

3)Channel (通道)
NIO 通过Channel(通道) 进行读写。

通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和Buffer交互。因为 Buffer,通道可以异步地读写。

4)Selector (选择器)
NIO有选择器,而IO没有。

选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。线程之间的切换对于操作系统来说是昂贵的。
因此,为了提高系统效率选择器是有用的。

NIO 包含下面几个核心的组件:

Channel (通道)
Buffer (缓冲区)
Selector(选择器)

AIO (Asynchronous I/O)

AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,
操作系统会通知相应的线程进行后续的操作。

AIO 是异步IO的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。
对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。